Micron Document
🎖️GitЯра🎖️

Commit ea3dd629e50ce90542936315dbee3afbc3fe68eb


Parents : 034005b
Author : Ben Meadors <benmmeadors@gmail.com>
Date : 2026-07-05T17:36:30-05:00

feat(messaging): translate chat messages in-place with on-device ML Kit (google flavor only)

Adds Translate / Show original / Show translation actions to the message
long-press sheet, per the cross-platform standard in meshtastic/design#100.
Reuses the docs-translation architecture: MessageTranslationService in
feature/messaging commonMain, MlKitMessageTranslator (language-id detection,
user-consented model downloads) in the google source set only, and no-op
bindings for fdroid and desktop. Translations persist on the packet row
(translated_text, show_translated; Room v45 auto-migration) so the
original/translated toggle survives restarts, mirroring the iOS reference
implementation.

Changes

33 files changed, 2846 insertions(+), 12 deletions(-)


Diff

diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 87e70bf4fc..4441105936 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -21,6 +21,8 @@ action_select_message
action_select_network
action_send_reply
action_show_message_status
+action_toggle_translation
+action_translate_message
actions
adc_multiplier_override
adc_multiplier_override_ratio
@@ -578,6 +580,7 @@ firmware
firmware_edition
firmware_old
firmware_recovery_banner
+firmware_recovery_ble_failed
firmware_recovery_button
firmware_recovery_dismiss
firmware_recovery_explanation
@@ -928,6 +931,7 @@ message_status_queued
message_status_sfpp_confirmed
message_status_sfpp_routing
message_status_unknown
+message_translated_label
messages
micrograms_per_cubic_meter
min
@@ -1390,8 +1394,10 @@ should_update_firmware
show_all_key_title
show_iaq_legend
show_layer
+show_original
show_password
show_precision_circle
+show_translation
show_waypoints
shutdown
shutdown_node_name
@@ -1518,6 +1524,14 @@ traffic_management_rate_limit_max_packets
traffic_management_rate_limit_window
traffic_management_router_preserve_hops
traffic_management_unknown_packet_threshold
+translate
+### TRANSLATION ###
+translation_download_message
+translation_download_title
+translation_downloading
+translation_failed
+translation_model_download_failed
+translation_not_required
transmit_over_lora
### TRANSPORT ###
transport

diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index 281b9ee984..c99931af3f 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -311,6 +311,7 @@ dependencies {
googleImplementation(libs.firebase.ai)
googleImplementation(libs.firebase.ai.ondevice)
googleImplementation(libs.mlkit.translate)
+ googleImplementation(libs.mlkit.language.id)
googleImplementation(libs.mlkit.genai.prompt)
googleImplementation(libs.androidx.appfunctions)

diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FdroidAiModule.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FdroidAiModule.kt
index 2920bf22d1..581989fe08 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FdroidAiModule.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FdroidAiModule.kt
@@ -24,6 +24,8 @@ import org.meshtastic.feature.docs.ai.AIDocAssistant
import org.meshtastic.feature.docs.ai.KeywordFallbackAssistant
import org.meshtastic.feature.docs.translation.DocTranslationService
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
+import org.meshtastic.feature.messaging.translation.MessageTranslationService
+import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
/** Provides keyword-only fallback AI assistant for the F-Droid flavor (no on-device model). */
@Module
@@ -33,4 +35,6 @@ class FdroidAiModule {
@Single fun discoverySummaryAiProvider(fallback: AlgorithmicSummaryProvider): DiscoverySummaryAiProvider = fallback
@Single fun docTranslationService(): DocTranslationService = NoOpDocTranslator()
+
+ @Single fun messageTranslationService(): MessageTranslationService = NoOpMessageTranslator()
}

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/GoogleAiModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/GoogleAiModule.kt
index 121164eca5..96d02beff5 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/di/GoogleAiModule.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/GoogleAiModule.kt
@@ -24,6 +24,7 @@ import org.koin.core.annotation.Single
import org.meshtastic.app.ai.GeminiNanoDocAssistant
import org.meshtastic.app.discovery.GeminiNanoSummaryProvider
import org.meshtastic.app.translation.MlKitDocTranslator
+import org.meshtastic.app.translation.MlKitMessageTranslator
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.feature.discovery.DiscoverySummaryGenerator
import org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
@@ -32,6 +33,7 @@ import org.meshtastic.feature.docs.data.DocBundleLoader
import org.meshtastic.feature.docs.data.KeywordSearchEngine
import org.meshtastic.feature.docs.translation.DocTranslationCache
import org.meshtastic.feature.docs.translation.DocTranslationService
+import org.meshtastic.feature.messaging.translation.MessageTranslationService
// TODO: Enable Firebase App Check (with Play Integrity provider) if hybrid/cloud
// fallback is ever adopted. App Check only gates cloud proxy requests — on-device
@@ -56,4 +58,6 @@ class GoogleAiModule {
DocTranslationCache(cacheDir = context.cacheDir.toOkioPath(), fileSystem = FileSystem.SYSTEM)
@Single fun docTranslationService(cache: DocTranslationCache): DocTranslationService = MlKitDocTranslator(cache)
+
+ @Single fun messageTranslationService(): MessageTranslationService = MlKitMessageTranslator()
}

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/translation/MlKitMessageTranslator.kt b/androidApp/src/google/kotlin/org/meshtastic/app/translation/MlKitMessageTranslator.kt
new file mode 100644
index 0000000000..42fe4f43a1
--- /dev/null
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/translation/MlKitMessageTranslator.kt
@@ -0,0 +1,153 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app.translation
+
+import co.touchlab.kermit.Logger
+import com.google.mlkit.common.model.DownloadConditions
+import com.google.mlkit.common.model.RemoteModelManager
+import com.google.mlkit.nl.languageid.LanguageIdentification
+import com.google.mlkit.nl.translate.TranslateLanguage
+import com.google.mlkit.nl.translate.TranslateRemoteModel
+import com.google.mlkit.nl.translate.Translation
+import com.google.mlkit.nl.translate.TranslatorOptions
+import kotlinx.coroutines.suspendCancellableCoroutine
+import org.meshtastic.feature.messaging.translation.DownloadResult
+import org.meshtastic.feature.messaging.translation.MessageTranslationService
+import org.meshtastic.feature.messaging.translation.TranslationResult
+import kotlin.coroutines.resume
+
+/**
+ * ML Kit-powered chat message translation for the Google flavor.
+ *
+ * Detects the source language on-device, then translates to the target locale. Unlike [MlKitDocTranslator], missing
+ * language models (~30MB each) are never downloaded implicitly — [translate] reports them via
+ * [TranslationResult.ModelDownloadRequired] so the UI can ask the user first, then calls [downloadLanguageModels].
+ */
+class MlKitMessageTranslator : MessageTranslationService {
+
+ private val modelManager = RemoteModelManager.getInstance()
+
+ override suspend fun translate(text: String, targetLocale: String): TranslationResult {
+ if (text.isBlank()) return TranslationResult.Unavailable
+ val targetLang =
+ TranslateLanguage.fromLanguageTag(normalizeLanguageTag(targetLocale))
+ ?: return TranslationResult.Unavailable
+
+ val detectedTag = identifySourceLanguage(text)
+ if (detectedTag == UNDETERMINED_LANGUAGE_TAG) return TranslationResult.Unavailable
+ val sourceLang =
+ TranslateLanguage.fromLanguageTag(normalizeLanguageTag(detectedTag)) ?: return TranslationResult.Unavailable
+ if (sourceLang == targetLang) return TranslationResult.NotRequired
+
+ val missing = listOf(sourceLang, targetLang).filterNot { isModelDownloaded(it) }
+ if (missing.isNotEmpty()) {
+ return TranslationResult.ModelDownloadRequired(missing, ESTIMATED_MODEL_SIZE_MB * missing.size)
+ }
+
+ return try {
+ val options =
+ TranslatorOptions.Builder().setSourceLanguage(sourceLang).setTargetLanguage(targetLang).build()
+ val translator = Translation.getClient(options)
+ val translated =
+ try {
+ suspendCancellableCoroutine { cont ->
+ translator
+ .translate(text)
+ .addOnSuccessListener { cont.resume(it) }
+ .addOnFailureListener { e ->
+ Logger.w(tag = TAG) { "Translation failed: ${e.message}" }
+ cont.resume(null)
+ }
+ }
+ } finally {
+ translator.close()
+ }
+ translated?.let { TranslationResult.Success(it) } ?: TranslationResult.Unavailable
+ } catch (e: Exception) {
+ Logger.w(tag = TAG) { "Translation to $targetLocale failed: ${e.message}" }
+ TranslationResult.Unavailable
+ }
+ }
+
+ /** Available means ML Kit can translate into [locale], even if the model still needs downloading. */
+ override suspend fun isLanguageAvailable(locale: String): Boolean =
+ TranslateLanguage.fromLanguageTag(normalizeLanguageTag(locale)) != null
+
+ override suspend fun downloadLanguageModels(languageTags: List<String>): DownloadResult {
+ languageTags.forEach { tag ->
+ val lang =
+ TranslateLanguage.fromLanguageTag(normalizeLanguageTag(tag))
+ ?: return DownloadResult.Failed("Unsupported language: $tag")
+ val result = downloadModel(lang)
+ if (result is DownloadResult.Failed) {
+ Logger.w(tag = TAG) { "Model download for $tag failed: ${result.reason}" }
+ return result
+ }
+ }
+ return DownloadResult.Success
+ }
+
+ private suspend fun identifySourceLanguage(text: String): String {
+ val client = LanguageIdentification.getClient()
+ return try {
+ suspendCancellableCoroutine { cont ->
+ client
+ .identifyLanguage(text)
+ .addOnSuccessListener { cont.resume(it) }
+ .addOnFailureListener { e ->
+ Logger.w(tag = TAG) { "Language identification failed: ${e.message}" }
+ cont.resume(UNDETERMINED_LANGUAGE_TAG)
+ }
+ }
+ } finally {
+ client.close()
+ }
+ }
+
+ private suspend fun downloadModel(lang: String): DownloadResult {
+ val model = TranslateRemoteModel.Builder(lang).build()
+ val conditions = DownloadConditions.Builder().build()
+ return suspendCancellableCoroutine { cont ->
+ modelManager
+ .download(model, conditions)
+ .addOnSuccessListener { cont.resume(DownloadResult.Success) }
+ .addOnFailureListener { e -> cont.resume(DownloadResult.Failed(e.message ?: "Download failed")) }
+ }
+ }
+
+ private suspend fun isModelDownloaded(lang: String): Boolean = suspendCancellableCoroutine { cont ->
+ val model = TranslateRemoteModel.Builder(lang).build()
+ modelManager
+ .isModelDownloaded(model)
+ .addOnSuccessListener { cont.resume(it) }
+ .addOnFailureListener { cont.resume(false) }
+ }
+
+ /** ML Kit expects modern ISO 639-1 codes; Android's [java.util.Locale] can emit legacy ones. */
+ private fun normalizeLanguageTag(tag: String): String = when (tag) {
+ "iw" -> "he"
+ "in" -> "id"
+ "ji" -> "yi"
+ else -> tag
+ }
+
+ companion object {
+ private const val TAG = "MlKitMessageTranslator"
+ private const val ESTIMATED_MODEL_SIZE_MB = 30
+ private const val UNDETERMINED_LANGUAGE_TAG = "und"
+ }
+}

diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts
index c4a164e187..a3d273839f 100644
--- a/core/data/build.gradle.kts
+++ b/core/data/build.gradle.kts
@@ -66,5 +66,12 @@ kotlin {
implementation(projects.core.testing)
implementation(libs.kotlinx.coroutines.test)
}
+
+ val androidHostTest by getting {
+ dependencies {
+ // JVM variant provides the host-platform native for BundledSQLiteDriver (same as core:database)
+ runtimeOnly("androidx.sqlite:sqlite-bundled-jvm:2.7.0")
+ }
+ }
}
}

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
index c6ebdbac3b..c708c34e6b 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
@@ -236,6 +236,12 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
override suspend fun updateMessageId(d: DataPacket, id: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageId(d, id) }
+ override suspend fun setMessageTranslation(uuid: Long, translatedText: String) =
+ withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().setTranslation(uuid, translatedText) }
+
+ override suspend fun setShowTranslated(uuid: Long, showTranslated: Boolean) =
+ withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().setShowTranslated(uuid, showTranslated) }
+
override suspend fun getPacketById(id: Int): DataPacket? =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketById(id)?.data }

diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/PacketTranslationRepositoryTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/PacketTranslationRepositoryTest.kt
new file mode 100644
index 0000000000..0eff2e14ad
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/PacketTranslationRepositoryTest.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.repository
+
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import okio.ByteString.Companion.toByteString
+import org.meshtastic.core.database.entity.MyNodeEntity
+import org.meshtastic.core.database.entity.Packet
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.NodeAddress
+import org.meshtastic.core.testing.FakeDatabaseProvider
+import org.meshtastic.proto.PortNum
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class PacketTranslationRepositoryTest {
+
+ private lateinit var dbProvider: FakeDatabaseProvider
+ private lateinit var repository: PacketRepositoryImpl
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
+
+ private val myNodeNum = 42424242
+ private val contactKey = "0${NodeAddress.ID_BROADCAST}"
+
+ @BeforeTest
+ fun setUp() {
+ dbProvider = FakeDatabaseProvider()
+ repository = PacketRepositoryImpl(dbProvider, dispatchers)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ dbProvider.close()
+ }
+
+ private suspend fun seedTextPacket(): Long {
+ val db = dbProvider.currentDb.value
+ db.nodeInfoDao()
+ .setMyNodeInfo(
+ MyNodeEntity(
+ myNodeNum = myNodeNum,
+ model = null,
+ firmwareVersion = null,
+ couldUpdate = false,
+ shouldUpdate = false,
+ currentPacketId = 1L,
+ messageTimeoutMsec = 5 * 60 * 1000,
+ minAppVersion = 1,
+ maxChannels = 8,
+ hasWifi = false,
+ ),
+ )
+ db.packetDao()
+ .insert(
+ Packet(
+ uuid = 0L,
+ myNodeNum = myNodeNum,
+ port_num = PortNum.TEXT_MESSAGE_APP.value,
+ contact_key = contactKey,
+ received_time = 1000L,
+ read = true,
+ data =
+ DataPacket(
+ to = NodeAddress.ID_BROADCAST,
+ bytes = "Hola".encodeToByteArray().toByteString(),
+ dataType = PortNum.TEXT_MESSAGE_APP.value,
+ ),
+ ),
+ )
+ return packet().uuid
+ }
+
+ private suspend fun packet(): Packet =
+ dbProvider.currentDb.value.packetDao().getMessagesFrom(contactKey).first().single().packet
+
+ @Test
+ fun setMessageTranslationPersistsAndShowsTranslation() = runTest(testDispatcher) {
+ val uuid = seedTextPacket()
+
+ repository.setMessageTranslation(uuid, "Hello")
+
+ val updated = packet()
+ assertEquals("Hello", updated.translatedText)
+ assertTrue(updated.showTranslated)
+ }
+
+ @Test
+ fun setShowTranslatedTogglesDisplayState() = runTest(testDispatcher) {
+ val uuid = seedTextPacket()
+ repository.setMessageTranslation(uuid, "Hello")
+
+ repository.setShowTranslated(uuid, false)
+ assertFalse(packet().showTranslated)
+ assertEquals("Hello", packet().translatedText)
+
+ repository.setShowTranslated(uuid, true)
+ assertTrue(packet().showTranslated)
+ }
+}

diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/45.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/45.json
new file mode 100644
index 0000000000..8b493b0464
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/45.json
@@ -0,0 +1,1600 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 45,
+ "identityHash": "5817384dfd5740d824e98ab5d5a3bef2",
+ "entities": [
+ {
+ "tableName": "my_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareVersion",
+ "columnName": "firmwareVersion",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "couldUpdate",
+ "columnName": "couldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shouldUpdate",
+ "columnName": "shouldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentPacketId",
+ "columnName": "currentPacketId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageTimeoutMsec",
+ "columnName": "messageTimeoutMsec",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minAppVersion",
+ "columnName": "minAppVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxChannels",
+ "columnName": "maxChannels",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasWifi",
+ "columnName": "hasWifi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pioEnv",
+ "columnName": "pioEnv",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum"
+ ]
+ }
+ },
+ {
+ "tableName": "nodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, `has_xeddsa_signed` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user",
+ "columnName": "user",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastHeard",
+ "columnName": "last_heard",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceTelemetry",
+ "columnName": "device_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viaMqtt",
+ "columnName": "via_mqtt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hops_away",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isIgnored",
+ "columnName": "is_ignored",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isMuted",
+ "columnName": "is_muted",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "environmentTelemetry",
+ "columnName": "environment_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerTelemetry",
+ "columnName": "power_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "airQualityTelemetry",
+ "columnName": "air_quality_metrics",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ },
+ {
+ "fieldPath": "paxcounter",
+ "columnName": "paxcounter",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "manuallyVerified",
+ "columnName": "manually_verified",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "nodeStatus",
+ "columnName": "node_status",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastTransport",
+ "columnName": "last_transport",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "signsPackets",
+ "columnName": "has_xeddsa_signed",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_nodes_last_heard",
+ "unique": false,
+ "columnNames": [
+ "last_heard"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)"
+ },
+ {
+ "name": "index_nodes_short_name",
+ "unique": false,
+ "columnNames": [
+ "short_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)"
+ },
+ {
+ "name": "index_nodes_long_name",
+ "unique": false,
+ "columnNames": [
+ "long_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)"
+ },
+ {
+ "name": "index_nodes_hops_away",
+ "unique": false,
+ "columnNames": [
+ "hops_away"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)"
+ },
+ {
+ "name": "index_nodes_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)"
+ },
+ {
+ "name": "index_nodes_last_heard_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "last_heard",
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)"
+ },
+ {
+ "name": "index_nodes_public_key",
+ "unique": false,
+ "columnNames": [
+ "public_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '', `translated_text` TEXT, `show_translated` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "port_num",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_time",
+ "columnName": "received_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "data",
+ "columnName": "data",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "filtered",
+ "columnName": "filtered",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "translatedText",
+ "columnName": "translated_text",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "showTranslated",
+ "columnName": "show_translated",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_packet_myNodeNum",
+ "unique": false,
+ "columnNames": [
+ "myNodeNum"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
+ },
+ {
+ "name": "index_packet_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ },
+ {
+ "name": "index_packet_contact_key",
+ "unique": false,
+ "columnNames": [
+ "contact_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
+ },
+ {
+ "name": "index_packet_contact_key_port_num_received_time",
+ "unique": false,
+ "columnNames": [
+ "contact_key",
+ "port_num",
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)"
+ },
+ {
+ "name": "index_packet_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ },
+ {
+ "name": "index_packet_received_time",
+ "unique": false,
+ "columnNames": [
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)"
+ },
+ {
+ "name": "index_packet_filtered",
+ "unique": false,
+ "columnNames": [
+ "filtered"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)"
+ },
+ {
+ "name": "index_packet_read",
+ "unique": false,
+ "columnNames": [
+ "read"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet_fts",
+ "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)",
+ "fields": [
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": []
+ },
+ "ftsVersion": "FTS5",
+ "ftsOptions": {
+ "tokenizer": "unicode61",
+ "tokenizerArgs": [],
+ "contentTable": "packet",
+ "languageIdColumnName": "",
+ "matchInfo": "FTS4",
+ "notIndexedColumns": [],
+ "prefixSizes": [],
+ "preferredOrder": "ASC",
+ "contentRowId": "",
+ "columnSize": true,
+ "detail": "FULL"
+ },
+ "contentSyncTriggers": [
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END"
+ ]
+ },
+ {
+ "tableName": "contact_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))",
+ "fields": [
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "muteUntil",
+ "columnName": "muteUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadMessageUuid",
+ "columnName": "last_read_message_uuid",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastReadMessageTimestamp",
+ "columnName": "last_read_message_timestamp",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "filteringDisabled",
+ "columnName": "filtering_disabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "contact_key"
+ ]
+ }
+ },
+ {
+ "tableName": "log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message_type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_date",
+ "columnName": "received_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "raw_message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromNum",
+ "columnName": "from_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "portNum",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "fromRadio",
+ "columnName": "from_radio",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_log_from_num",
+ "unique": false,
+ "columnNames": [
+ "from_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
+ },
+ {
+ "name": "index_log_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "quick_chat",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ }
+ },
+ {
+ "tableName": "reactions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "replyId",
+ "columnName": "reply_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "emoji",
+ "columnName": "emoji",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relays",
+ "columnName": "relays",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relayNode",
+ "columnName": "relay_node",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "to",
+ "columnName": "to",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum",
+ "reply_id",
+ "user_id",
+ "emoji"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_reactions_reply_id",
+ "unique": false,
+ "columnNames": [
+ "reply_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
+ },
+ {
+ "name": "index_reactions_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proto",
+ "columnName": "proto",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_metadata_num",
+ "unique": false,
+ "columnNames": [
+ "num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "device_hardware",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))",
+ "fields": [
+ {
+ "fieldPath": "activelySupported",
+ "columnName": "actively_supported",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "architecture",
+ "columnName": "architecture",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasInkHud",
+ "columnName": "has_ink_hud",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hasMui",
+ "columnName": "has_mui",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hwModel",
+ "columnName": "hwModel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hwModelSlug",
+ "columnName": "hw_model_slug",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "partitionScheme",
+ "columnName": "partition_scheme",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "platformioTarget",
+ "columnName": "platformio_target",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresDfu",
+ "columnName": "requires_dfu",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "supportLevel",
+ "columnName": "support_level",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "platformio_target"
+ ]
+ }
+ },
+ {
+ "tableName": "device_link",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`short_code` TEXT NOT NULL, `link_description` TEXT, `is_vendor` INTEGER NOT NULL, `regions` TEXT, `targets` TEXT, PRIMARY KEY(`short_code`))",
+ "fields": [
+ {
+ "fieldPath": "shortCode",
+ "columnName": "short_code",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "linkDescription",
+ "columnName": "link_description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isVendor",
+ "columnName": "is_vendor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "regions",
+ "columnName": "regions",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "targets",
+ "columnName": "targets",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "short_code"
+ ]
+ }
+ },
+ {
+ "tableName": "firmware_release",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageUrl",
+ "columnName": "page_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseNotes",
+ "columnName": "release_notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "zipUrl",
+ "columnName": "zip_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseType",
+ "columnName": "release_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "traceroute_node_position",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "logUuid",
+ "columnName": "log_uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "request_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "log_uuid",
+ "node_num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_traceroute_node_position_log_uuid",
+ "unique": false,
+ "columnNames": [
+ "log_uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)"
+ },
+ {
+ "name": "index_traceroute_node_position_request_id",
+ "unique": false,
+ "columnNames": [
+ "request_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "log",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "log_uuid"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "discovery_session",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `presets_scanned` TEXT NOT NULL, `home_preset` TEXT NOT NULL, `total_unique_nodes` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `total_messages` INTEGER NOT NULL DEFAULT 0, `total_sensor_packets` INTEGER NOT NULL DEFAULT 0, `furthest_node_distance` REAL NOT NULL DEFAULT 0.0, `completion_status` TEXT NOT NULL DEFAULT 'complete', `ai_summary` TEXT, `user_latitude` REAL NOT NULL DEFAULT 0.0, `user_longitude` REAL NOT NULL DEFAULT 0.0, `total_dwell_seconds` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetsScanned",
+ "columnName": "presets_scanned",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "homePreset",
+ "columnName": "home_preset",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalUniqueNodes",
+ "columnName": "total_unique_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "avgChannelUtilization",
+ "columnName": "avg_channel_utilization",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "totalMessages",
+ "columnName": "total_messages",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "totalSensorPackets",
+ "columnName": "total_sensor_packets",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "furthestNodeDistance",
+ "columnName": "furthest_node_distance",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "completionStatus",
+ "columnName": "completion_status",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'complete'"
+ },
+ {
+ "fieldPath": "aiSummary",
+ "columnName": "ai_summary",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "userLatitude",
+ "columnName": "user_latitude",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "userLongitude",
+ "columnName": "user_longitude",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "totalDwellSeconds",
+ "columnName": "total_dwell_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "discovery_preset_result",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` INTEGER NOT NULL, `preset_name` TEXT NOT NULL, `dwell_duration_seconds` INTEGER NOT NULL DEFAULT 0, `unique_nodes` INTEGER NOT NULL DEFAULT 0, `direct_neighbor_count` INTEGER NOT NULL DEFAULT 0, `mesh_neighbor_count` INTEGER NOT NULL DEFAULT 0, `infrastructure_node_count` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `avg_airtime_rate` REAL NOT NULL DEFAULT 0.0, `packet_success_rate` REAL NOT NULL DEFAULT 0.0, `packet_failure_rate` REAL NOT NULL DEFAULT 0.0, `ai_summary` TEXT, `num_packets_tx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx_bad` INTEGER NOT NULL DEFAULT 0, `num_rx_dupe` INTEGER NOT NULL DEFAULT 0, `num_tx_relay` INTEGER NOT NULL DEFAULT 0, `num_tx_relay_canceled` INTEGER NOT NULL DEFAULT 0, `num_online_nodes` INTEGER NOT NULL DEFAULT 0, `num_total_nodes` INTEGER NOT NULL DEFAULT 0, `uptime_seconds` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`session_id`) REFERENCES `discovery_session`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionId",
+ "columnName": "session_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetName",
+ "columnName": "preset_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dwellDurationSeconds",
+ "columnName": "dwell_duration_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "uniqueNodes",
+ "columnName": "unique_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "directNeighborCount",
+ "columnName": "direct_neighbor_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "meshNeighborCount",
+ "columnName": "mesh_neighbor_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "infrastructureNodeCount",
+ "columnName": "infrastructure_node_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageCount",
+ "columnName": "message_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sensorPacketCount",
+ "columnName": "sensor_packet_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "avgChannelUtilization",
+ "columnName": "avg_channel_utilization",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "avgAirtimeRate",
+ "columnName": "avg_airtime_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "packetSuccessRate",
+ "columnName": "packet_success_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "packetFailureRate",
+ "columnName": "packet_failure_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "aiSummary",
+ "columnName": "ai_summary",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "numPacketsTx",
+ "columnName": "num_packets_tx",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numPacketsRx",
+ "columnName": "num_packets_rx",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numPacketsRxBad",
+ "columnName": "num_packets_rx_bad",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numRxDupe",
+ "columnName": "num_rx_dupe",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTxRelay",
+ "columnName": "num_tx_relay",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTxRelayCanceled",
+ "columnName": "num_tx_relay_canceled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numOnlineNodes",
+ "columnName": "num_online_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTotalNodes",
+ "columnName": "num_total_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "uptimeSeconds",
+ "columnName": "uptime_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_discovery_preset_result_session_id",
+ "unique": false,
+ "columnNames": [
+ "session_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovery_preset_result_session_id` ON `${TABLE_NAME}` (`session_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "discovery_session",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "session_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "discovered_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `preset_result_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `short_name` TEXT, `long_name` TEXT, `neighbor_type` TEXT NOT NULL DEFAULT 'direct', `latitude` REAL, `longitude` REAL, `distance_from_user` REAL, `hop_count` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `is_infrastructure` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`preset_result_id`) REFERENCES `discovery_preset_result`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetResultId",
+ "columnName": "preset_result_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "neighborType",
+ "columnName": "neighbor_type",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'direct'"
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "distanceFromUser",
+ "columnName": "distance_from_user",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "hopCount",
+ "columnName": "hop_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageCount",
+ "columnName": "message_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sensorPacketCount",
+ "columnName": "sensor_packet_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isInfrastructure",
+ "columnName": "is_infrastructure",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_discovered_node_preset_result_id",
+ "unique": false,
+ "columnNames": [
+ "preset_result_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_preset_result_id` ON `${TABLE_NAME}` (`preset_result_id`)"
+ },
+ {
+ "name": "index_discovered_node_node_num",
+ "unique": false,
+ "columnNames": [
+ "node_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_node_num` ON `${TABLE_NAME}` (`node_num`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "discovery_preset_result",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "preset_result_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5817384dfd5740d824e98ab5d5a3bef2')"
+ ]
+ }
+}
\ No newline at end of file

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index 2c7c5a30eb..b50dc17e6e 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -114,8 +114,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 41, to = 42),
AutoMigration(from = 42, to = 43, spec = AutoMigration42to43::class),
AutoMigration(from = 43, to = 44),
+ AutoMigration(from = 44, to = 45),
],
- version = 44,
+ version = 45,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
index 06c14dcbb7..a7083e37d0 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
@@ -548,6 +548,13 @@ interface PacketDao {
)
suspend fun updateFilteredBySender(senderIdPattern: String, filtered: Boolean)
+ /** Persists an on-device translation and switches the message to display it in one write. */
+ @Query("UPDATE packet SET translated_text = :translatedText, show_translated = 1 WHERE uuid = :uuid")
+ suspend fun setTranslation(uuid: Long, translatedText: String)
+
+ @Query("UPDATE packet SET show_translated = :show WHERE uuid = :uuid")
+ suspend fun setShowTranslated(uuid: Long, show: Boolean)
+
// region ── FTS5 Search ──
@Query(

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
index 16057e8ebb..26c5f0ec61 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
@@ -62,6 +62,8 @@ data class PacketEntity(
filtered = filtered,
transportMechanism = data.transportMechanism,
xeddsaSigned = data.xeddsaSigned,
+ translatedText = translatedText,
+ showTranslated = showTranslated,
)
}
}
@@ -97,6 +99,8 @@ data class Packet(
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null,
@ColumnInfo(name = "filtered", defaultValue = "0") val filtered: Boolean = false,
@ColumnInfo(name = "message_text", defaultValue = "") val messageText: String = "",
+ @ColumnInfo(name = "translated_text") val translatedText: String? = null,
+ @ColumnInfo(name = "show_translated", defaultValue = "0") val showTranslated: Boolean = false,
) {
companion object {
const val RELAY_NODE_SUFFIX_MASK = 0xFF

diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketTranslationDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketTranslationDaoTest.kt
new file mode 100644
index 0000000000..0ec758a4c7
--- /dev/null
+++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketTranslationDaoTest.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.database.dao
+
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import okio.ByteString.Companion.toByteString
+import org.meshtastic.core.database.MeshtasticDatabase
+import org.meshtastic.core.database.entity.MyNodeEntity
+import org.meshtastic.core.database.entity.Packet
+import org.meshtastic.core.database.getInMemoryDatabaseBuilder
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.NodeAddress
+import org.meshtastic.proto.PortNum
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class PacketTranslationDaoTest {
+
+ private lateinit var database: MeshtasticDatabase
+ private lateinit var packetDao: PacketDao
+
+ private val myNodeInfo =
+ MyNodeEntity(
+ myNodeNum = 42424242,
+ model = null,
+ firmwareVersion = null,
+ couldUpdate = false,
+ shouldUpdate = false,
+ currentPacketId = 1L,
+ messageTimeoutMsec = 5 * 60 * 1000,
+ minAppVersion = 1,
+ maxChannels = 8,
+ hasWifi = false,
+ )
+
+ private val contactKey = "0${NodeAddress.ID_BROADCAST}"
+
+ @BeforeTest
+ fun setUp() {
+ database = getInMemoryDatabaseBuilder().build()
+ packetDao = database.packetDao()
+ }
+
+ @AfterTest
+ fun tearDown() {
+ database.close()
+ }
+
+ private suspend fun seedTextPacket(): Long {
+ database.nodeInfoDao().setMyNodeInfo(myNodeInfo)
+ packetDao.insert(
+ Packet(
+ uuid = 0L,
+ myNodeNum = myNodeInfo.myNodeNum,
+ port_num = PortNum.TEXT_MESSAGE_APP.value,
+ contact_key = contactKey,
+ received_time = 1000L,
+ read = true,
+ data =
+ DataPacket(
+ to = NodeAddress.ID_BROADCAST,
+ bytes = "Hola, ¿cómo estás?".encodeToByteArray().toByteString(),
+ dataType = PortNum.TEXT_MESSAGE_APP.value,
+ ),
+ ),
+ )
+ return packet().uuid
+ }
+
+ private suspend fun packet(): Packet = packetDao.getMessagesFrom(contactKey).first().single().packet
+
+ @Test
+ fun setTranslationPersistsTextAndShowsIt() = runTest {
+ val uuid = seedTextPacket()
+ assertNull(packet().translatedText)
+ assertFalse(packet().showTranslated)
+
+ packetDao.setTranslation(uuid, "Hello, how are you?")
+
+ val updated = packet()
+ assertEquals("Hello, how are you?", updated.translatedText)
+ assertTrue(updated.showTranslated)
+ }
+
+ @Test
+ fun setShowTranslatedFlipsOnlyTheFlag() = runTest {
+ val uuid = seedTextPacket()
+ packetDao.setTranslation(uuid, "Hello, how are you?")
+
+ packetDao.setShowTranslated(uuid, false)
+ val original = packet()
+ assertEquals("Hello, how are you?", original.translatedText)
+ assertFalse(original.showTranslated)
+
+ packetDao.setShowTranslated(uuid, true)
+ val translated = packet()
+ assertEquals("Hello, how are you?", translated.translatedText)
+ assertTrue(translated.showTranslated)
+ }
+}

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt
index ce0eaebf84..232369a637 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt
@@ -97,6 +97,10 @@ data class Message(
val transportMechanism: Int = 0,
/** True when the radio verified this broadcast's XEdDSA signature ([MeshPacket.xeddsa_signed]). */
val xeddsaSigned: Boolean = false,
+ /** On-device translation of [text], persisted so the user can toggle back to it without re-translating. */
+ val translatedText: String? = null,
+ /** Whether the bubble currently displays [translatedText] instead of [text]. */
+ val showTranslated: Boolean = false,
) {
fun getStatusStringRes(): Pair<StringResource, StringResource> {
val title = if (routingError > 0) Res.string.error else Res.string.message_delivery_status

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
index 4f83ff2abe..377b75ad68 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
@@ -123,6 +123,12 @@ interface PacketRepository {
/** Updates the identifier of a persisted packet. */
suspend fun updateMessageId(d: DataPacket, id: Int)
+ /** Persists the on-device translation of a message and switches it to display the translation. */
+ suspend fun setMessageTranslation(uuid: Long, translatedText: String)
+
+ /** Toggles whether a translated message displays the translation or the original text. */
+ suspend fun setShowTranslated(uuid: Long, showTranslated: Boolean)
+
/** Deletes messages by their database UUIDs. */
suspend fun deleteMessages(uuidList: List<Long>)

diff --git a/core/resources/src/commonMain/composeResources/drawable/ic_translate.xml b/core/resources/src/commonMain/composeResources/drawable/ic_translate.xml
new file mode 100644
index 0000000000..501acda07a
--- /dev/null
+++ b/core/resources/src/commonMain/composeResources/drawable/ic_translate.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+ <path
+ android:fillColor="#FFFFFFFF"
+ android:pathData="M476,880L658,400L742,400L924,880L840,880L797,758L603,758L560,880L476,880ZM160,760L104,704L306,502Q271,467 242.5,422Q214,377 190,320L274,320Q294,359 314,388Q334,417 362,446Q395,413 430.5,353.5Q466,294 484,240L40,240L40,160L320,160L320,80L400,80L400,160L680,160L680,240L564,240Q543,312 501,388Q459,464 418,504L514,602L484,684L362,559L160,760ZM628,688L772,688L700,484L628,688Z"/>
+</vector>

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index be31a0daad..3a60a9b3b7 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -39,6 +39,8 @@
<string name="action_select_network">Select network</string>
<string name="action_send_reply">Send reply</string>
<string name="action_show_message_status">Show message status</string>
+ <string name="action_toggle_translation">Toggle between original and translated text</string>
+ <string name="action_translate_message">Translate message</string>
<string name="actions">Actions</string>
<string name="adc_multiplier_override">ADC multiplier override</string>
<string name="adc_multiplier_override_ratio">ADC multiplier override ratio</string>
@@ -602,10 +604,10 @@
<string name="firmware_edition">Firmware Edition</string>
<string name="firmware_old">The radio firmware is too old to talk to this application. For more information on this see <a href="https://meshtastic.org/docs/getting-started/flashing-firmware">our Firmware Installation guide</a>.</string>
<string name="firmware_recovery_banner">Finish updating %1$s</string>
- <string name="firmware_recovery_button">Resume firmware update</string>
- <string name="firmware_recovery_explanation">This device is in bootloader mode from a firmware update that did not finish. Keep it nearby and it will be re-flashed to complete the update.</string>
<string name="firmware_recovery_ble_failed">Couldn\'t finish the update over Bluetooth. This device\'s stock bootloader can\'t reliably complete an interrupted update over the air. Connect it to a computer with USB and re-flash it using the vendor\'s serial DFU tool (for example adafruit-nrfutil) to recover the device.</string>
+ <string name="firmware_recovery_button">Resume firmware update</string>
<string name="firmware_recovery_dismiss">Dismiss firmware recovery</string>
+ <string name="firmware_recovery_explanation">This device is in bootloader mode from a firmware update that did not finish. Keep it nearby and it will be re-flashed to complete the update.</string>
<string name="firmware_too_old">Firmware update required.</string>
<string name="firmware_update_almost_there">Almost there...</string>
<string name="firmware_update_alpha">Alpha</string>
@@ -959,6 +961,7 @@
<string name="message_status_sfpp_confirmed">Confirmed on SF++ chain</string>
<string name="message_status_sfpp_routing">Routing via SF++ chain…</string>
<string name="message_status_unknown">Unknown</string>
+ <string name="message_translated_label">Translated</string>
<string name="messages">Messages</string>
<string name="micrograms_per_cubic_meter">µg/m³</string>
<string name="min">Min</string>
@@ -1433,8 +1436,10 @@
<string name="show_all_key_title">Encryption Key Meanings</string>
<string name="show_iaq_legend">Show air quality legend</string>
<string name="show_layer">Show Layer</string>
+ <string name="show_original">Show original</string>
<string name="show_password">Show password</string>
<string name="show_precision_circle">Show Precision Circles</string>
+ <string name="show_translation">Show translation</string>
<string name="show_waypoints">Show Waypoints</string>
<string name="shutdown">Shutdown</string>
<string name="shutdown_node_name">Node: %1$s</string>
@@ -1564,6 +1569,14 @@
<string name="traffic_management_rate_limit_window">Rate Limit Window (secs)</string>
<string name="traffic_management_router_preserve_hops">Preserve Router Hops</string>
<string name="traffic_management_unknown_packet_threshold">Unknown Packet Threshold</string>
+ <string name="translate">Translate</string>
+ <!-- TRANSLATION -->
+ <string name="translation_download_message">Translating messages requires a one-time language model download (about %1$d MB).</string>
+ <string name="translation_download_title">Download translation model?</string>
+ <string name="translation_downloading">Downloading translation model…</string>
+ <string name="translation_failed">Unable to translate message</string>
+ <string name="translation_model_download_failed">Translation model download failed</string>
+ <string name="translation_not_required">Message is already in your language</string>
<string name="transmit_over_lora">Transmit over LoRa</string>
<!-- TRANSPORT -->
<string name="transport">Transport</string>

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt
index 9da4588c65..9996bdd627 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Actions.kt
@@ -57,6 +57,7 @@ import org.meshtastic.core.resources.ic_share
import org.meshtastic.core.resources.ic_sort
import org.meshtastic.core.resources.ic_system_update
import org.meshtastic.core.resources.ic_thumb_up
+import org.meshtastic.core.resources.ic_translate
import org.meshtastic.core.resources.ic_upload
val MeshtasticIcons.Add: ImageVector
@@ -136,3 +137,5 @@ val MeshtasticIcons.BarChart: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_bar_chart)
val MeshtasticIcons.List: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_list)
+val MeshtasticIcons.Translate: ImageVector
+ @Composable get() = vectorResource(Res.drawable.ic_translate)

diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
index 7ba51244ca..ab1e44c75a 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
@@ -87,6 +87,8 @@ import org.meshtastic.feature.docs.ai.AIDocAssistant
import org.meshtastic.feature.docs.ai.KeywordFallbackAssistant
import org.meshtastic.feature.docs.translation.DocTranslationService
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
+import org.meshtastic.feature.messaging.translation.MessageTranslationService
+import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
import org.meshtastic.feature.node.compass.CompassHeadingProvider
import org.meshtastic.feature.node.compass.MagneticFieldProvider
import org.meshtastic.feature.node.compass.PhoneLocationProvider
@@ -225,6 +227,7 @@ private fun desktopPlatformStubsModule() = module {
single<AIDocAssistant> { get<KeywordFallbackAssistant>() }
single<DiscoverySummaryAiProvider> { get<AlgorithmicSummaryProvider>() }
single<DocTranslationService> { NoOpDocTranslator() }
+ single<MessageTranslationService> { NoOpMessageTranslator() }
// Desktop uses the real ApiService implementation (no flavor stub needed)
single<ApiService> { ApiServiceImpl(client = get()) }

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
index 4c09915e63..0784efc46f 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
@@ -97,6 +97,7 @@ import org.meshtastic.feature.messaging.component.MessageTopBar
import org.meshtastic.feature.messaging.component.QuickChatRow
import org.meshtastic.feature.messaging.component.ReplySnippet
import org.meshtastic.feature.messaging.component.ScrollToBottomFab
+import org.meshtastic.feature.messaging.component.TranslationModelDownloadDialog
private const val ROUNDED_CORNER_PERCENT = 100
private const val MAX_LINES = 3
@@ -151,6 +152,8 @@ fun MessageScreen(
val searchResults by viewModel.searchResults.collectAsStateWithLifecycle()
val searchResultIndex by viewModel.searchResultIndex.collectAsStateWithLifecycle()
val currentSearchResult by viewModel.currentSearchResult.collectAsStateWithLifecycle()
+ val translationAvailable by viewModel.translationAvailable.collectAsStateWithLifecycle()
+ val translationDialogState by viewModel.translationDialogState.collectAsStateWithLifecycle()
// Sync text field changes back to ViewModel draft
LaunchedEffect(messageInputState) {
@@ -283,6 +286,10 @@ fun MessageScreen(
coroutineScope.launch { clipboardManager.setClipEntry(createClipEntry(event.text, event.text)) }
selectedMessageIds.value = emptySet()
}
+
+ is MessageScreenEvent.TranslateMessage -> viewModel.translateMessage(event.message)
+
+ is MessageScreenEvent.ToggleShowTranslated -> viewModel.toggleShowTranslated(event.message)
}
}
@@ -297,6 +304,12 @@ fun MessageScreen(
)
}
+ TranslationModelDownloadDialog(
+ state = translationDialogState,
+ onConfirm = viewModel::confirmTranslationModelDownload,
+ onDismiss = viewModel::dismissTranslationDialog,
+ )
+
sharedContact?.let { contact -> SharedContactDialog(contact = contact, onDismiss = { sharedContact = null }) }
val originalMessage by
@@ -319,7 +332,10 @@ fun MessageScreen(
(0 until pagedMessages.itemCount)
.mapNotNull { pagedMessages[it] }
.filter { it.uuid in selectedMessageIds.value }
- .joinToString("\n") { it.text }
+ .joinToString("\n") {
+ // Copy what the bubble displays (matches the sheet's Copy action)
+ if (it.showTranslated) it.translatedText ?: it.text else it.text
+ }
onEvent(MessageScreenEvent.CopyToClipboard(copiedText))
}
@@ -425,6 +441,7 @@ fun MessageScreen(
showFiltered = showFiltered,
filteringDisabled = filteringDisabled,
searchQuery = if (isSearchActive) searchQuery else "",
+ translationAvailable = translationAvailable,
),
handlers =
MessageListHandlers(
@@ -436,6 +453,8 @@ fun MessageScreen(
onDeleteMessages = { viewModel.deleteMessages(it) },
onSendMessage = { text, key -> viewModel.sendMessage(text, key) },
onReply = { message -> replyingToPacketId = message?.packetId },
+ onTranslate = { onEvent(MessageScreenEvent.TranslateMessage(it)) },
+ onToggleTranslation = { onEvent(MessageScreenEvent.ToggleShowTranslated(it)) },
),
quickEmojis = viewModel.frequentEmojis,
)

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
index df864139a0..620fa377e5 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
@@ -70,6 +70,8 @@ internal data class MessageListHandlers(
val onDeleteMessages: (List<Long>) -> Unit,
val onSendMessage: (String, String) -> Unit,
val onReply: (Message?) -> Unit,
+ val onTranslate: (Message) -> Unit = {},
+ val onToggleTranslation: (Message) -> Unit = {},
)
internal data class MessageListPagedState(
@@ -84,6 +86,7 @@ internal data class MessageListPagedState(
val showFiltered: Boolean = false,
val filteringDisabled: Boolean = false,
val searchQuery: String = "",
+ val translationAvailable: Boolean = false,
)
private fun MutableState<Set<Long>>.toggle(uuid: Long) {
@@ -370,6 +373,9 @@ private fun RenderPagedChatMessageRow(
hasSameNext = hasSameNext,
quickEmojis = quickEmojis,
searchQuery = state.searchQuery,
+ translationAvailable = state.translationAvailable,
+ onTranslate = { handlers.onTranslate(message) },
+ onToggleTranslation = { handlers.onToggleTranslation(message) },
)
}

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt
index 4aeb5eb1c9..bd67c52041 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageScreenEvent.kt
@@ -16,10 +16,17 @@
*/
package org.meshtastic.feature.messaging
+import org.meshtastic.core.model.Message
import org.meshtastic.core.model.Node
/** Defines the various user interactions that can occur on the MessageScreen. */
internal sealed interface MessageScreenEvent {
+ /** Translate a message into the device language (the translation is persisted). */
+ data class TranslateMessage(val message: Message) : MessageScreenEvent
+
+ /** Toggle a translated message between showing the original and the translated text. */
+ data class ToggleShowTranslated(val message: Message) : MessageScreenEvent
+
/** Send a new text message. */
data class SendMessage(val text: String, val replyingToPacketId: Int? = null) : MessageScreenEvent

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
index 672b9a9e23..b1b7f79016 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
@@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.common.util.currentLocaleCode
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.ContactSettings
import org.meshtastic.core.model.Message
@@ -51,10 +52,38 @@ import org.meshtastic.core.repository.QuickChatActionRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.repository.usecase.SendMessageUseCase
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.UiText
+import org.meshtastic.core.resources.translation_failed
+import org.meshtastic.core.resources.translation_model_download_failed
+import org.meshtastic.core.resources.translation_not_required
+import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
+import org.meshtastic.feature.messaging.translation.DownloadResult
+import org.meshtastic.feature.messaging.translation.MessageTranslationService
+import org.meshtastic.feature.messaging.translation.TranslationResult
import org.meshtastic.proto.ChannelSet
+/**
+ * Test seam for resolving [UiText] to a plain string. Unit tests replace [resolve] so ViewModel snackbar paths don't
+ * depend on the compose-resources runtime (same pattern as NodeDetailUiTextResolver).
+ */
+internal object MessagingUiTextResolver {
+ var resolve: suspend (UiText) -> String = { it.resolve() }
+}
+
+/** State of the translation-model download dialog on the message screen. */
+sealed interface TranslationDialogState {
+ data object Hidden : TranslationDialogState
+
+ /** Ask the user to confirm downloading the missing [languageTags] models (~[estimatedSizeMb] MB). */
+ data class DownloadPrompt(val message: Message, val languageTags: List<String>, val estimatedSizeMb: Int) :
+ TranslationDialogState
+
+ data class Downloading(val message: Message) : TranslationDialogState
+}
+
@Suppress("LongParameterList", "TooManyFunctions")
@KoinViewModel
class MessageViewModel(
@@ -70,6 +99,8 @@ class MessageViewModel(
private val homoglyphEncodingPrefs: HomoglyphPrefs,
private val notificationManager: NotificationManager,
private val sendMessageUseCase: SendMessageUseCase,
+ private val messageTranslationService: MessageTranslationService,
+ private val snackbarManager: SnackbarManager,
) : ViewModel() {
private val _title = MutableStateFlow("")
val title: StateFlow<String> = _title.asStateFlow()
@@ -302,6 +333,71 @@ class MessageViewModel(
fun deleteMessages(uuidList: List<Long>) =
safeLaunch(context = ioDispatcher, tag = "deleteMessages") { packetRepository.deleteMessages(uuidList) }
+ // region ── Translation ──
+
+ /** Whether on-device translation into the current locale is possible (always false on F-Droid/desktop). */
+ val translationAvailable: StateFlow<Boolean> =
+ flow { emit(messageTranslationService.isLanguageAvailable(currentLocaleCode())) }
+ .stateInWhileSubscribed(initialValue = false)
+
+ private val _translationDialogState = MutableStateFlow<TranslationDialogState>(TranslationDialogState.Hidden)
+ val translationDialogState: StateFlow<TranslationDialogState> = _translationDialogState.asStateFlow()
+
+ /** Translates [message] into the device language, or just re-shows a previously persisted translation. */
+ // Translation methods use plain safeLaunch (like sendMessage): every call inside is a main-safe suspend
+ // function (the repository hops to IO internally), and tests stay on the deterministic test scheduler.
+ fun translateMessage(message: Message) = safeLaunch(tag = "translateMessage") {
+ if (message.translatedText != null) {
+ packetRepository.setShowTranslated(message.uuid, true)
+ return@safeLaunch
+ }
+ when (val result = messageTranslationService.translate(message.text, currentLocaleCode())) {
+ is TranslationResult.Success ->
+ packetRepository.setMessageTranslation(message.uuid, result.translatedText)
+
+ is TranslationResult.NotRequired ->
+ snackbarManager.showSnackbar(
+ MessagingUiTextResolver.resolve(UiText.Resource(Res.string.translation_not_required)),
+ )
+
+ is TranslationResult.ModelDownloadRequired ->
+ _translationDialogState.value =
+ TranslationDialogState.DownloadPrompt(message, result.languageTags, result.estimatedSizeMb)
+
+ is TranslationResult.Unavailable ->
+ snackbarManager.showSnackbar(
+ MessagingUiTextResolver.resolve(UiText.Resource(Res.string.translation_failed)),
+ )
+ }
+ }
+
+ fun toggleShowTranslated(message: Message) = safeLaunch(tag = "toggleShowTranslated") {
+ packetRepository.setShowTranslated(message.uuid, !message.showTranslated)
+ }
+
+ fun confirmTranslationModelDownload() {
+ val prompt = _translationDialogState.value as? TranslationDialogState.DownloadPrompt ?: return
+ safeLaunch(tag = "downloadTranslationModel") {
+ _translationDialogState.value = TranslationDialogState.Downloading(prompt.message)
+ val result = messageTranslationService.downloadLanguageModels(prompt.languageTags)
+ _translationDialogState.value = TranslationDialogState.Hidden
+ when (result) {
+ is DownloadResult.Success -> translateMessage(prompt.message)
+
+ is DownloadResult.Failed ->
+ snackbarManager.showSnackbar(
+ MessagingUiTextResolver.resolve(UiText.Resource(Res.string.translation_model_download_failed)),
+ )
+ }
+ }
+ }
+
+ fun dismissTranslationDialog() {
+ _translationDialogState.value = TranslationDialogState.Hidden
+ }
+
+ // endregion
+
fun clearUnreadCount(contact: String, messageUuid: Long, lastReadTimestamp: Long) =
safeLaunch(context = ioDispatcher, tag = "clearUnreadCount") {
val existingTimestamp = contactSettings.value[contact]?.lastReadMessageTimestamp ?: Long.MIN_VALUE

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt
index b90e91f39e..3230f2b5b0 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageActionsBottomSheet.kt
@@ -48,6 +48,8 @@ import org.meshtastic.core.resources.action_react_with_emoji
import org.meshtastic.core.resources.action_select_message
import org.meshtastic.core.resources.action_send_reply
import org.meshtastic.core.resources.action_show_message_status
+import org.meshtastic.core.resources.action_toggle_translation
+import org.meshtastic.core.resources.action_translate_message
import org.meshtastic.core.resources.copy
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.device_metrics_label_value
@@ -57,6 +59,9 @@ import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.security_signed_message_info
import org.meshtastic.core.resources.security_signed_verified
import org.meshtastic.core.resources.select
+import org.meshtastic.core.resources.show_original
+import org.meshtastic.core.resources.show_translation
+import org.meshtastic.core.resources.translate
import org.meshtastic.core.ui.icon.AddReaction
import org.meshtastic.core.ui.icon.Copy
import org.meshtastic.core.ui.icon.Delete
@@ -64,6 +69,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Reply
import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.core.ui.icon.ShieldCheck
+import org.meshtastic.core.ui.icon.Translate
@Suppress("LongMethod")
@Composable
@@ -75,10 +81,13 @@ fun MessageActionsContent(
onCopy: () -> Unit,
onSelect: () -> Unit,
onDelete: () -> Unit,
+ onStatus: () -> Unit,
statusString: Pair<StringResource, StringResource>? = null,
status: MessageStatus? = null,
xeddsaSigned: Boolean = false,
- onStatus: (() -> Unit),
+ translationRowState: TranslationRowState? = null,
+ onTranslate: () -> Unit = {},
+ onToggleTranslation: () -> Unit = {},
) {
Column {
QuickEmojiRow(quickEmojis = quickEmojis, onReact = onReact, onMoreReactions = onMoreReactions)
@@ -140,6 +149,35 @@ fun MessageActionsContent(
),
)
+ if (translationRowState != null) {
+ val headline =
+ when (translationRowState) {
+ TranslationRowState.Translate -> stringResource(Res.string.translate)
+ TranslationRowState.ShowOriginal -> stringResource(Res.string.show_original)
+ TranslationRowState.ShowTranslation -> stringResource(Res.string.show_translation)
+ }
+ val onClickLabel =
+ when (translationRowState) {
+ TranslationRowState.Translate -> stringResource(Res.string.action_translate_message)
+ else -> stringResource(Res.string.action_toggle_translation)
+ }
+ ListItem(
+ headlineContent = { Text(headline) },
+ leadingContent = { Icon(MeshtasticIcons.Translate, contentDescription = headline) },
+ modifier =
+ Modifier.clickable(
+ onClickLabel = onClickLabel,
+ role = Role.Button,
+ onClick =
+ if (translationRowState == TranslationRowState.Translate) {
+ onTranslate
+ } else {
+ onToggleTranslation
+ },
+ ),
+ )
+ }
+
ListItem(
headlineContent = { Text(stringResource(Res.string.select)) },
leadingContent = {

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
index 8c5fc4b1ef..90ce389838 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt
@@ -64,6 +64,7 @@ import org.meshtastic.core.model.Reaction
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.a11y_message_from
import org.meshtastic.core.resources.filter_message_label
+import org.meshtastic.core.resources.message_translated_label
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.security_signed_verified
import org.meshtastic.core.ui.component.AutoLinkText
@@ -114,6 +115,9 @@ fun MessageItem(
hasSamePrev: Boolean = false,
hasSameNext: Boolean = false,
searchQuery: String = "",
+ translationAvailable: Boolean = false,
+ onTranslate: () -> Unit = {},
+ onToggleTranslation: () -> Unit = {},
) = Column(
modifier =
modifier
@@ -132,6 +136,9 @@ fun MessageItem(
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isLocal = node.num == ourNode.num
+ // While searching, always show the original text — FTS matches and highlights apply to it, not the translation.
+ val showsTranslation = message.showTranslated && message.translatedText != null && searchQuery.isEmpty()
+ val bodyText = if (showsTranslation) message.translatedText.orEmpty() else message.text
if (activeSheet != null) {
ModalBottomSheet(onDismissRequest = { activeSheet = null }, sheetState = sheetState) {
when (activeSheet) {
@@ -150,7 +157,7 @@ fun MessageItem(
onCopy = {
activeSheet = null
coroutineScope.launch {
- clipboardManager.setClipEntry(createClipEntry(message.text, "message"))
+ clipboardManager.setClipEntry(createClipEntry(bodyText, "message"))
}
},
onSelect = {
@@ -170,6 +177,29 @@ fun MessageItem(
},
xeddsaSigned = message.xeddsaSigned,
onStatus = onStatusClick,
+ translationRowState =
+ when {
+ // Toggling a persisted translation is just a DB flag flip — offer it even when
+ // the translation engine is no longer available for the current locale.
+ message.translatedText != null ->
+ if (message.showTranslated) {
+ TranslationRowState.ShowOriginal
+ } else {
+ TranslationRowState.ShowTranslation
+ }
+
+ !translationAvailable || message.text.isBlank() -> null
+
+ else -> TranslationRowState.Translate
+ },
+ onTranslate = {
+ activeSheet = null
+ onTranslate()
+ },
+ onToggleTranslation = {
+ activeSheet = null
+ onToggleTranslation()
+ },
)
}
@@ -220,7 +250,7 @@ fun MessageItem(
},
)
val senderName = if (message.fromLocal) ourNode.user.long_name else node.user.long_name
- val messageA11yText = stringResource(Res.string.a11y_message_from, senderName, message.text)
+ val messageA11yText = stringResource(Res.string.a11y_message_from, senderName, bodyText)
if (showUserName && !message.fromLocal) {
Row(
modifier = Modifier.padding(horizontal = 8.dp),
@@ -280,11 +310,7 @@ fun MessageItem(
color = contentColor,
)
} else {
- AutoLinkText(
- text = message.text,
- style = MaterialTheme.typography.bodyLarge,
- color = contentColor,
- )
+ AutoLinkText(text = bodyText, style = MaterialTheme.typography.bodyLarge, color = contentColor)
}
Row(
@@ -346,6 +372,14 @@ fun MessageItem(
modifier = Modifier.padding(start = 8.dp, end = 4.dp),
)
}
+ if (showsTranslation) {
+ Text(
+ text = stringResource(Res.string.message_translated_label),
+ style = metadataStyle,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 8.dp, end = 4.dp),
+ )
+ }
if (message.fromLocal) {
MessageStatusIcon(
status = message.status ?: MessageStatus.UNKNOWN,

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt
index 53cf61bff9..415ed7a105 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItemPreviews.kt
@@ -145,6 +145,27 @@ private fun MessageItemPreview() {
originalMessage = received,
viaMqtt = true,
)
+ val translatedMessage =
+ Message(
+ text = "Hola, ¿cómo estás?",
+ translatedText = "Hello, how are you?",
+ showTranslated = true,
+ time = "10:25",
+ fromLocal = false,
+ status = MessageStatus.RECEIVED,
+ snr = 2.0f,
+ rssi = 80,
+ hopsAway = 1,
+ uuid = 4L,
+ receivedTime = nowMillis,
+ node = NodePreviewParameterProvider().minnieMouse,
+ read = false,
+ routingError = 0,
+ packetId = 4547,
+ emojis = listOf(),
+ replyId = null,
+ viaMqtt = false,
+ )
val filteredMessage =
Message(
text = "This message was filtered",
@@ -215,6 +236,22 @@ private fun MessageItemPreview() {
onNavigateToOriginalMessage = {},
)
+ MessageItem(
+ message = translatedMessage,
+ node = translatedMessage.node,
+ selected = false,
+ ourNode = sent.node,
+ translationAvailable = true,
+ onReply = {},
+ sendReaction = {},
+ onShowReactions = {},
+ onClick = {},
+ onLongClick = {},
+ onDoubleClick = {},
+ onClickChip = {},
+ onNavigateToOriginalMessage = {},
+ )
+
MessageItem(
message = filteredMessage,
node = filteredMessage.node,

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt
index 2bdf552503..8273cc569b 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
@@ -36,6 +37,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuGroup
import androidx.compose.material3.DropdownMenuItem
@@ -72,6 +74,7 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alert_bell_text
+import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.cancel_reply
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.clear_selection
@@ -79,6 +82,7 @@ import org.meshtastic.core.resources.copy
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.delete_messages
import org.meshtastic.core.resources.delete_messages_title
+import org.meshtastic.core.resources.download
import org.meshtastic.core.resources.filter_disable_for_contact
import org.meshtastic.core.resources.filter_enable_for_contact
import org.meshtastic.core.resources.filter_hide_count
@@ -95,7 +99,11 @@ import org.meshtastic.core.resources.replying_to
import org.meshtastic.core.resources.scroll_to_bottom
import org.meshtastic.core.resources.search_messages
import org.meshtastic.core.resources.select_all
+import org.meshtastic.core.resources.translation_download_message
+import org.meshtastic.core.resources.translation_download_title
+import org.meshtastic.core.resources.translation_downloading
import org.meshtastic.core.resources.unknown
+import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.MeshtasticTextDialog
import org.meshtastic.core.ui.component.NodeKeyStatusIcon
import org.meshtastic.core.ui.component.SecurityIcon
@@ -120,6 +128,7 @@ import org.meshtastic.core.ui.icon.Unmuted
import org.meshtastic.core.ui.icon.Visibility
import org.meshtastic.core.ui.icon.VisibilityOff
import org.meshtastic.feature.messaging.DeliveryInfo
+import org.meshtastic.feature.messaging.TranslationDialogState
import org.meshtastic.proto.ChannelSet
// region ── ScrollToBottomFab ──
@@ -232,6 +241,54 @@ fun DeleteMessageDialog(count: Int, onConfirm: () -> Unit, onDismiss: () -> Unit
// endregion
+// region ── TranslationModelDownloadDialog ──
+
+/**
+ * A dialog asking the user to confirm the one-time download of on-device translation models, then showing indeterminate
+ * progress while they download. Rendered as nothing when [state] is [TranslationDialogState.Hidden].
+ *
+ * @param state The current dialog state from [org.meshtastic.feature.messaging.MessageViewModel].
+ * @param onConfirm Callback invoked when the user confirms the model download.
+ * @param onDismiss Callback invoked when the prompt is dismissed.
+ */
+@Composable
+internal fun TranslationModelDownloadDialog(
+ state: TranslationDialogState,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ when (state) {
+ is TranslationDialogState.Hidden -> {}
+
+ is TranslationDialogState.DownloadPrompt ->
+ MeshtasticTextDialog(
+ titleRes = Res.string.translation_download_title,
+ message = stringResource(Res.string.translation_download_message, state.estimatedSizeMb),
+ confirmTextRes = Res.string.download,
+ dismissTextRes = Res.string.cancel,
+ onConfirm = onConfirm,
+ onDismiss = onDismiss,
+ )
+
+ is TranslationDialogState.Downloading ->
+ MeshtasticDialog(
+ titleRes = Res.string.translation_download_title,
+ text = {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ Text(stringResource(Res.string.translation_downloading))
+ }
+ },
+ dismissable = false,
+ )
+ }
+}
+
+// endregion
+
// region ── ActionModeTopBar & MessageMenuAction ──
/** Actions available in the message selection mode's top bar. */

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/TranslationRowState.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/TranslationRowState.kt
new file mode 100644
index 0000000000..bbbdb0f6ce
--- /dev/null
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/TranslationRowState.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.messaging.component
+
+/** Which translation action row the message actions sheet shows, if any (null = row hidden). */
+enum class TranslationRowState {
+ Translate,
+ ShowOriginal,
+ ShowTranslation,
+}

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/translation/MessageTranslationService.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/translation/MessageTranslationService.kt
new file mode 100644
index 0000000000..309ed64644
--- /dev/null
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/translation/MessageTranslationService.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.messaging.translation
+
+/**
+ * Service interface for translating chat messages on-device.
+ *
+ * Google flavor provides an ML Kit implementation; fdroid/desktop/iOS provide a no-op that returns
+ * [TranslationResult.Unavailable], which hides the translate action entirely.
+ */
+interface MessageTranslationService {
+ /**
+ * Translate plain chat text to the target locale, detecting the source language on-device. Never downloads language
+ * models — missing models are reported as [TranslationResult.ModelDownloadRequired] so the UI can ask the user
+ * before pulling ~30MB per language.
+ */
+ suspend fun translate(text: String, targetLocale: String): TranslationResult
+
+ /** Check whether translating into the given locale is possible on this platform (model downloadable). */
+ suspend fun isLanguageAvailable(locale: String): Boolean
+
+ /** Download the translation models for the given language tags. Only meaningful on google flavor. */
+ suspend fun downloadLanguageModels(languageTags: List<String>): DownloadResult
+}
+
+/** Result of a message translation attempt. */
+sealed class TranslationResult {
+ /** Translation succeeded. [translatedText] contains the message text in the target locale. */
+ data class Success(val translatedText: String) : TranslationResult()
+
+ /** The message is already in the target language; there is nothing to translate or persist. */
+ data object NotRequired : TranslationResult()
+
+ /** The models for [languageTags] must be downloaded (an estimated [estimatedSizeMb]) before translating. */
+ data class ModelDownloadRequired(val languageTags: List<String>, val estimatedSizeMb: Int) : TranslationResult()
+
+ /** Translation is not available on this platform/flavor, or the source language could not be determined. */
+ data object Unavailable : TranslationResult()
+}
+
+/** Result of a model download attempt. */
+sealed class DownloadResult {
+ data object Success : DownloadResult()
+
+ data class Failed(val reason: String) : DownloadResult()
+}

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/translation/NoOpMessageTranslator.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/translation/NoOpMessageTranslator.kt
new file mode 100644
index 0000000000..b3ca5e0359
--- /dev/null
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/translation/NoOpMessageTranslator.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.messaging.translation
+
+/**
+ * No-op message translation service for platforms without on-device translation capability (F-Droid, Desktop, iOS).
+ *
+ * Always reports translation as unavailable, which hides the translate action in the message UI.
+ */
+class NoOpMessageTranslator : MessageTranslationService {
+ override suspend fun translate(text: String, targetLocale: String): TranslationResult =
+ TranslationResult.Unavailable
+
+ override suspend fun isLanguageAvailable(locale: String): Boolean = false
+
+ override suspend fun downloadLanguageModels(languageTags: List<String>): DownloadResult =
+ DownloadResult.Failed("Translation not available on this platform")
+}

diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
index 04caf016b2..3e6ba9b7ea 100644
--- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt
@@ -45,6 +45,8 @@ import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.TestDataFactory
+import org.meshtastic.core.ui.util.SnackbarManager
+import org.meshtastic.feature.messaging.translation.MessageTranslationService
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.LocalConfig
@@ -70,6 +72,8 @@ class MessageViewModelTest {
private val homoglyphPrefs: HomoglyphPrefs = mock(MockMode.autofill)
private val uiPrefs: UiPrefs = mock(MockMode.autofill)
private val notificationManager: org.meshtastic.core.repository.NotificationManager = mock(MockMode.autofill)
+ private val messageTranslationService: MessageTranslationService = mock(MockMode.autofill)
+ private val snackbarManager: SnackbarManager = SnackbarManager()
private val testDispatcher = StandardTestDispatcher()
@@ -124,6 +128,8 @@ class MessageViewModelTest {
homoglyphEncodingPrefs = homoglyphPrefs,
uiPrefs = uiPrefs,
notificationManager = notificationManager,
+ messageTranslationService = messageTranslationService,
+ snackbarManager = snackbarManager,
)
}

diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTranslationTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTranslationTest.kt
new file mode 100644
index 0000000000..1b39abbdc2
--- /dev/null
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTranslationTest.kt
@@ -0,0 +1,336 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.messaging
+
+import androidx.compose.material3.SnackbarDuration
+import androidx.lifecycle.SavedStateHandle
+import app.cash.turbine.test
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import dev.mokkery.verify.VerifyMode
+import dev.mokkery.verifySuspend
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.ContactSettings
+import org.meshtastic.core.model.Message
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.repository.ConnectionStateProvider
+import org.meshtastic.core.repository.CustomEmojiPrefs
+import org.meshtastic.core.repository.HomoglyphPrefs
+import org.meshtastic.core.repository.MessagingController
+import org.meshtastic.core.repository.NotificationManager
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.QuickChatActionRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.UiPrefs
+import org.meshtastic.core.repository.usecase.SendMessageUseCase
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.UiText
+import org.meshtastic.core.resources.translation_failed
+import org.meshtastic.core.resources.translation_model_download_failed
+import org.meshtastic.core.resources.translation_not_required
+import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.testing.TestDataFactory
+import org.meshtastic.core.ui.util.SnackbarManager
+import org.meshtastic.feature.messaging.translation.DownloadResult
+import org.meshtastic.feature.messaging.translation.MessageTranslationService
+import org.meshtastic.feature.messaging.translation.TranslationResult
+import org.meshtastic.proto.ChannelSet
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+private const val SNACKBAR_NOT_REQUIRED = "already-in-your-language"
+private const val SNACKBAR_FAILED = "translation-failed"
+private const val SNACKBAR_DOWNLOAD_FAILED = "model-download-failed"
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MessageViewModelTranslationTest {
+
+ private class FakeMessageTranslationService : MessageTranslationService {
+ var translateResult: TranslationResult = TranslationResult.Unavailable
+ var languageAvailable = false
+ var downloadResult: DownloadResult = DownloadResult.Success
+ var translateCalls = 0
+ var availabilityChecks = 0
+ val downloadedTags = mutableListOf<List<String>>()
+
+ override suspend fun translate(text: String, targetLocale: String): TranslationResult {
+ translateCalls++
+ return translateResult
+ }
+
+ override suspend fun isLanguageAvailable(locale: String): Boolean {
+ availabilityChecks++
+ return languageAvailable
+ }
+
+ override suspend fun downloadLanguageModels(languageTags: List<String>): DownloadResult {
+ downloadedTags += languageTags
+ return downloadResult
+ }
+ }
+
+ private class RecordingSnackbarManager : SnackbarManager() {
+ val messages = mutableListOf<String>()
+
+ override fun showSnackbar(
+ message: String,
+ actionLabel: String?,
+ withDismissAction: Boolean,
+ duration: SnackbarDuration,
+ onAction: (() -> Unit)?,
+ ) {
+ messages += message
+ }
+ }
+
+ private lateinit var viewModel: MessageViewModel
+ private val translationService = FakeMessageTranslationService()
+ private val snackbarManager = RecordingSnackbarManager()
+
+ private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ private val quickChatActionRepository: QuickChatActionRepository = mock(MockMode.autofill)
+ private val packetRepository: PacketRepository = mock(MockMode.autofill)
+ private val connectionStateProvider: ConnectionStateProvider = mock(MockMode.autofill)
+ private val messagingController: MessagingController = mock(MockMode.autofill)
+ private val sendMessageUseCase: SendMessageUseCase = mock(MockMode.autofill)
+ private val customEmojiPrefs: CustomEmojiPrefs = mock(MockMode.autofill)
+ private val homoglyphPrefs: HomoglyphPrefs = mock(MockMode.autofill)
+ private val uiPrefs: UiPrefs = mock(MockMode.autofill)
+ private val notificationManager: NotificationManager = mock(MockMode.autofill)
+
+ private val testDispatcher = StandardTestDispatcher()
+
+ private val message =
+ Message(
+ uuid = 7L,
+ receivedTime = 1000L,
+ node = TestDataFactory.createTestNodes(1).first(),
+ text = "Hola, ¿cómo estás?",
+ fromLocal = false,
+ time = "10:00",
+ read = true,
+ status = MessageStatus.RECEIVED,
+ routingError = 0,
+ packetId = 123,
+ emojis = emptyList(),
+ snr = 2.5f,
+ rssi = 90,
+ hopsAway = 0,
+ replyId = null,
+ )
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ MessagingUiTextResolver.resolve = { text ->
+ when (text) {
+ is UiText.DynamicString -> text.value
+
+ is UiText.Resource ->
+ when (text.res) {
+ Res.string.translation_not_required -> SNACKBAR_NOT_REQUIRED
+ Res.string.translation_failed -> SNACKBAR_FAILED
+ Res.string.translation_model_download_failed -> SNACKBAR_DOWNLOAD_FAILED
+ else -> error("Unexpected UiText resource in test: ${text.res}")
+ }
+ }
+ }
+
+ every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
+ every { connectionStateProvider.connectionState } returns
+ MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
+ every { customEmojiPrefs.customEmojiFrequency } returns MutableStateFlow<String?>(null)
+ every { homoglyphPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false)
+ every { uiPrefs.showQuickChat } returns MutableStateFlow(false)
+ every { packetRepository.getContactSettings() } returns
+ MutableStateFlow<Map<String, ContactSettings>>(emptyMap())
+ every { quickChatActionRepository.getAllActions() } returns MutableStateFlow(emptyList())
+
+ viewModel =
+ MessageViewModel(
+ savedStateHandle = SavedStateHandle(mapOf("contactKey" to "0!12345678")),
+ nodeRepository = FakeNodeRepository(),
+ radioConfigRepository = radioConfigRepository,
+ quickChatActionRepository = quickChatActionRepository,
+ connectionStateProvider = connectionStateProvider,
+ messagingController = messagingController,
+ packetRepository = packetRepository,
+ sendMessageUseCase = sendMessageUseCase,
+ customEmojiPrefs = customEmojiPrefs,
+ homoglyphEncodingPrefs = homoglyphPrefs,
+ uiPrefs = uiPrefs,
+ notificationManager = notificationManager,
+ messageTranslationService = translationService,
+ snackbarManager = snackbarManager,
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ MessagingUiTextResolver.resolve = { it.resolve() }
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun translationAvailableIsFalseWhenServiceUnavailable() = runTest {
+ translationService.languageAvailable = false
+ viewModel.translationAvailable.test {
+ assertEquals(false, awaitItem())
+ // Drain the upstream availability check so this asserts the queried result, not just the initial value.
+ advanceUntilIdle()
+ expectNoEvents()
+ cancelAndIgnoreRemainingEvents()
+ }
+ assertTrue(translationService.availabilityChecks >= 1)
+ }
+
+ @Test
+ fun translationAvailableIsTrueWhenServiceSupportsLocale() = runTest {
+ translationService.languageAvailable = true
+ viewModel.translationAvailable.test {
+ assertEquals(false, awaitItem())
+ assertEquals(true, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun translateSuccessPersistsTranslation() = runTest {
+ translationService.translateResult = TranslationResult.Success("Hello, how are you?")
+
+ viewModel.translateMessage(message)
+ advanceUntilIdle()
+
+ assertEquals(1, translationService.translateCalls)
+ verifySuspend { packetRepository.setMessageTranslation(7L, "Hello, how are you?") }
+ assertTrue(snackbarManager.messages.isEmpty())
+ }
+
+ @Test
+ fun translateWithCachedTranslationOnlyTogglesDisplay() = runTest {
+ viewModel.translateMessage(message.copy(translatedText = "Hello", showTranslated = false))
+ advanceUntilIdle()
+
+ assertEquals(0, translationService.translateCalls)
+ verifySuspend { packetRepository.setShowTranslated(7L, true) }
+ verifySuspend(VerifyMode.exactly(0)) { packetRepository.setMessageTranslation(7L, "Hello") }
+ }
+
+ @Test
+ fun translateNotRequiredShowsSnackbarAndPersistsNothing() = runTest {
+ translationService.translateResult = TranslationResult.NotRequired
+
+ viewModel.translateMessage(message)
+ advanceUntilIdle()
+
+ assertEquals(listOf(SNACKBAR_NOT_REQUIRED), snackbarManager.messages)
+ verifySuspend(VerifyMode.exactly(0)) { packetRepository.setMessageTranslation(7L, "Hello, how are you?") }
+ }
+
+ @Test
+ fun translateUnavailableShowsSnackbar() = runTest {
+ translationService.translateResult = TranslationResult.Unavailable
+
+ viewModel.translateMessage(message)
+ advanceUntilIdle()
+
+ assertEquals(listOf(SNACKBAR_FAILED), snackbarManager.messages)
+ }
+
+ @Test
+ fun modelDownloadRequiredShowsPrompt() = runTest {
+ translationService.translateResult =
+ TranslationResult.ModelDownloadRequired(languageTags = listOf("es", "en"), estimatedSizeMb = 60)
+
+ viewModel.translateMessage(message)
+ advanceUntilIdle()
+
+ val state = assertIs<TranslationDialogState.DownloadPrompt>(viewModel.translationDialogState.value)
+ assertEquals(listOf("es", "en"), state.languageTags)
+ assertEquals(60, state.estimatedSizeMb)
+ assertEquals(message.uuid, state.message.uuid)
+ }
+
+ @Test
+ fun confirmDownloadSuccessAutoContinuesToTranslation() = runTest {
+ translationService.translateResult =
+ TranslationResult.ModelDownloadRequired(languageTags = listOf("es"), estimatedSizeMb = 30)
+ viewModel.translateMessage(message)
+ advanceUntilIdle()
+
+ translationService.translateResult = TranslationResult.Success("Hello, how are you?")
+ viewModel.confirmTranslationModelDownload()
+ advanceUntilIdle()
+
+ assertEquals(listOf(listOf("es")), translationService.downloadedTags)
+ assertEquals(TranslationDialogState.Hidden, viewModel.translationDialogState.value)
+ verifySuspend { packetRepository.setMessageTranslation(7L, "Hello, how are you?") }
+ }
+
+ @Test
+ fun confirmDownloadFailureShowsSnackbarAndHidesDialog() = runTest {
+ translationService.translateResult =
+ TranslationResult.ModelDownloadRequired(languageTags = listOf("es"), estimatedSizeMb = 30)
+ viewModel.translateMessage(message)
+ advanceUntilIdle()
+
+ translationService.downloadResult = DownloadResult.Failed("no network")
+ viewModel.confirmTranslationModelDownload()
+ advanceUntilIdle()
+
+ assertEquals(TranslationDialogState.Hidden, viewModel.translationDialogState.value)
+ assertEquals(listOf(SNACKBAR_DOWNLOAD_FAILED), snackbarManager.messages)
+ assertEquals(1, translationService.translateCalls)
+ }
+
+ @Test
+ fun dismissTranslationDialogHidesPrompt() = runTest {
+ translationService.translateResult =
+ TranslationResult.ModelDownloadRequired(languageTags = listOf("es"), estimatedSizeMb = 30)
+ viewModel.translateMessage(message)
+ advanceUntilIdle()
+
+ viewModel.dismissTranslationDialog()
+
+ assertEquals(TranslationDialogState.Hidden, viewModel.translationDialogState.value)
+ }
+
+ @Test
+ fun toggleShowTranslatedFlipsPersistedFlag() = runTest {
+ viewModel.toggleShowTranslated(message.copy(translatedText = "Hello", showTranslated = true))
+ advanceUntilIdle()
+ verifySuspend { packetRepository.setShowTranslated(7L, false) }
+
+ viewModel.toggleShowTranslated(message.copy(translatedText = "Hello", showTranslated = false))
+ advanceUntilIdle()
+ verifySuspend { packetRepository.setShowTranslated(7L, true) }
+ }
+}

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 70f82bfde0..500df2a280 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -65,6 +65,7 @@ maps-compose = "8.3.0"
# ML Kit
mlkit-barcode-scanning = "17.3.0"
mlkit-genai-prompt = "1.0.0-beta2"
+mlkit-language-id = "17.0.6"
mlkit-translate = "17.0.3"
# CameraX
@@ -201,6 +202,7 @@ maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", ve
maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" }
mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" }
mlkit-genai-prompt = { module = "com.google.mlkit:genai-prompt", version.ref = "mlkit-genai-prompt" }
+mlkit-language-id = { module = "com.google.mlkit:language-id", version.ref = "mlkit-language-id" }
mlkit-translate = { module = "com.google.mlkit:translate", version.ref = "mlkit-translate" }
play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" }
zxing-core = { module = "com.google.zxing:core", version = "3.5.4" }

Served by rngit 1.5.4 - Generated in 0.34s